Actors
Lo mejor para actores es Erlang, pero vemos todo en Scala con una librería que se llama Akka por cuestiones de simplicidad
Sincronización tradicional
Varios threads se pisan entre sí. Esto se resuelve:
- Demarcando regiones de código con semánticas para "no molestar"
- Asegurando que el acceso a estado compartido sea protegido
En Scala
todos los objetos tienen un lock: synchronized {...}
class BankAccount {
private var balance = 0
def deposit(amount: Int): Unit = synchronized {
if (amount > 0) balance = balance + amount
}
def withdraw(amount: Int): Int = synchronized {
if (0 < amount & amount <= balance) {
balance = balance - amount
balance
} else throw new Error("insufficient funds")
}
}
Qué es un Actor?
Lo que pretende el modelo de actores es pensar las interacciones de un sistema como personas interactuando entre sí. Lo modela como personas que envían mensajes entre sí.
Un actor es:
- Un objeto con identidad
- Que tiene comportamiento
- Y solo interactúa usando pasaje de mensajes asincrónico
Es OOP
+ Mensajes
, en definitiva. Forzado dentro de un sistema donde todo tiene que seguir este estándar.
Actor trait
// Se define un type alias llamado 'Receive'
/* Representa una función que maneja los mensajes que se le envían a un Actor
- Es una PartialFunction, lo que significa que puede no manejar cualquier posible input
- Toma un mensaje de cualquier tipo, y no devuelve nada ('Unit')
*/
type Receive = PartialFunction[Any, Unit]
Receive es esencialmente una función que recibe cualquier parámetro y no devuelve nada.
¿Por qué PartialFunction
?
Si las funciones matemáticas están bien definidas, deberían tener dominio para todos los reales. Si no, tienen "agujeros" en el dominio.
Trasladado a Scala y al contexto de Actors, que sea una función parcial quiere decir que hay valores que no soporta. Esto le sirve al lenguaje para hacer algún tipo de chequeo.
// Se define un trait llamado Actor
trait Actor {
// Este método abstracto DEBE ser implementado por el Actor en cuestión
// Define la lógica con la que se reciben los mensajes (qué se hace cuando se recibe un mensaje)
def receive: Receive
}
- Los
traits
en Scala son equivalentes a las interfaces de Java, o a lostraits
(justamente) de Rust receive
es un lambda.
Un Actor Simple
// La clase 'Counter' extiende el trait 'Actor' e implementa el método 'receive'
class Counter extends Actor {
// Variable mutable (contador actual)
var count = 0
// El método 'receive' define cómo el actor maneja los mensajes que le llegan
def receive = {
// Si el mensaje es el string "incr", incrementa el contador
case "incr" => count += 1
// El resto de mensajes se ignoran, porque la PartialFunction no define un caso para estos.
// Si se quiere definir un caso default, se usa el underscore (_)
}
}
Exponiendo el estado
class Counter extends Actor {
var count = 0
// Añadir el mensaje para get
def receive = {
case "incr" => count += 1
case ("get", customer: ActorRef) => customer ! count
}
}
!
es el operador para mandar mensajes enAkka
Akka
es la librería deScala
para actores
customer
es unActorRef
- get le manda el count a un actor que puede recibir un entero (
int
) en su métodoreceive
- get le manda el count a un actor que puede recibir un entero (
Ejemplo para apoyar lo anterior
// Es un actor simplón que recibe el contador y lo imprime
class Printer extends Actor {
def receive = {
// Que el nombre del parámetro sea el mismo en un actor y otro es casualidad
// Es buena práctica pero no es necesario
case count: Int => println("Printer received count: " + $count)
}
}
Uso:
// Inicializar el sistema de actores (posteriormente se ve cómo)
// Supongamos que counter y printer ya está inicializados anteriormente
counter ! "incr"
counter ! "incr"
counter ! "incr"
// Se le pide al contador que envíe su valor actual al printer
counter ! ("get", printer)
¿Cómo se mandan los mensajes?
trait Actor {
// 'self' es una referencia implícita a su propia instancia de actor
// Le permite al actor referirse a su propia dirección sin pasarla de manera explícita
implicit val self: ActorRef
// 'sender' nos da acceso a quien envía el mensaje que actualmente está siendo procesado
// Esto es útil para responder mensajes - se puede hacer `sender ! reply`
def sender: ActorRef
// ...
}
Qué es un ActorRef?
abstract class ActorRef {
// El "bang" o ! es la manera principal de enviarle un mensaje a otro actor
// - 'msg: Any': se puede mandar cualquier tipo de mensaje
// - 'implicit sender': el sender se pasa de manera implícita, de tal manera que el receptor sabe quién lo mandó
def !(msg: Any)(implicit sender: ActorRef = Actor.noSender): Unit
// `tell` es un alias para el !
// Hace que el llamado sea más explícito al pasar tanto el mensaje como el remitente
def tell(msg: Any, sender: ActorRef) = this.!(msg)(sender)
}
- En definitiva, un
ActorRef
es una referencia utilizable hacia unActor
- Se suelen pensar como la "dirección de mail" del actor en cuestión
- Justamente como el sender está implícito, si no le paso nada me lo mando a mí mismo
implicit
es syntax sugar de Scala
Usando el Sender
class Counter extends Actor {
var count = 0
def receive = {
case "incr" => count += 1
case "get" => sender ! count
}
}
Un ejemplo para verlo de afuera sería:
class Multiplier extends Actor {
def receive = {
case x: Int => if (x < 10) self ! (x * 2)
case ("ask", customer: ActorRef) => customer ! "get"
}
}
// Suponer multiplier ya inicializado
counter ! "incr"
counter ! "incr"
multiplier ! ("ask", counter) // --> esto va a multiplicar por 2 recursivamente de manera infinita el valor de counter
// El flujo va a ser multiplier ask => counter get => le mando count al multiplier => Multiplier se llama a sí mismo recursivamente hasta que sea mayor a 10
Nota: este ejemplo me lo crafteé yo
Interactuando con el Printer
class Printer extends Actor {
def receive = {
// Acá muestra que cuando le llega un mensaje cualquiera lo imprime
// y después le manda al sender un mensaje con un re texto
case count: Int =>
// Imprimir el count que le llegó
println(s"[${self.path.name}] received count: $count")
// Le mando un ACK a quien me lo envió
// `sender` me da acceso a la referencia de quien sea que me mandó el mensaje en primer lugar
sender ! s"Acknowledged count $count from ${self.path.name}"
}
}
class CounterClient(printer: ActorRef) extends Actor {
def receive = {
// Este actor recibe el ack del Printer
case ack: String => println(s"[${self.path.name}] got reply: $ack")
}
// Este método se ejecuta on init del objeto
override def preStart(): Unit = {
// envía un número al printer usando '!' (asynchronous fire-and-forget)
// 'self' se va a usar implícitamente como sender
// Esta instancia de CounterClient va a ser el sender la primera vez
printer ! 42
// Le mando otro número de manera explícita usando 'tell' y 'self'
printer.tell(99, self)
}
}
- En un programa estándar con estos 2 objetos instanciados:
- se le manda al printer un 42
- luego se le manda un 99
- en ambos casos con el CounterClient como Sender
Actor Context
En el modelo de los actores, el contexto es el ambiente donde el actor está corriendo. Dentro de lo que puede hacer, le puedo pedir al contexto:
- Crear otros actores
- Cambiar su comportamiento de manera dinámica
- Acceder a referencias de sí mismo y de los remitentes
- "Frenarse" a sí mismo o a otros actores
El actor describe el comportamiento, la ejecución la realiza su ActorContext
En código
trait ActorContext {
// Me permite actualizarle el receive al actor actual
def become(behavior: Receive, discardOld: Boolean = true): Unit
// Vuelve para atrás al último comportamiento guardado en caso de que
// discardOld era `false` en el llamado del become
def unbecome(): Unit
}
- Otros métodos útiles del contexto pueden ser:
actorOf(...)
para instanciar actores hijosstop(...)
para frenar un actorself
,sender
,parent
,children
Ejemplo
class ToggleActor extends Actor {
def on: Receive = {
case "switch" =>
println("Turning off...")
context.become(off)
}
def off: Receive = {
case "switch" =>
println("Turning on...")
context.become(on)
}
// Este es el comportamiento inicial, arranca prendido
def receive = on
}
Functional Counter
Se puede definir a la clase Counter de manera funcional (sin variables mutables):
class Counter extends Actor {
def counter(n: Int): Receive = {
case "incr" => context.become(counter(n + 1))
case "get" => sender ! n
}
def receive = counter(0)
}
- Se crea un lambda con un parámetro preseteado
- En el fondo guarda el valor del parámetro en la definición del lambda
- Cuando se instancia el Counter, n = 0
Crear y detener actores
Definiendo el trait de ActorContext...
trait ActorContext {
// Se spawnea un actor hijo del actor actual
// - 'p' : es un objeto `Props`, define el tipo de actor y los parámetros de su constructor
// - 'name': es un nombre único para este nuevo actor dentro del contexto actual
def actorOf(p: Props, name: String): ActorRef
// Se frena o termina el actor
def stop(a: ActorRef): Unit
}
Aplicación completa de actores
class CounterMain extends Actor {
// Create an instance of the Counter actor as a child of this actor
val counter: ActorRef = context.actorOf(Props[Counter], "counter")
// Send some increment messages to the counter
counter ! "incr"
counter ! "incr"
counter ! "incr"
// Ask the counter to send its current value back (reply goes to this actor)
counter ! "get"
// This actor handles the reply from the counter
def receive: Receive = {
case count: Int =>
println(s"Count was $count") // Print the count
context.stop(self) // Stop this actor (ends the app)
}
}
El main sobre el que corre:
object CounterMainApp extends App {
// Create the actor system
val system = ActorSystem("CounterSystem")
// Create the main actor that orchestrates everything
system.actorOf(Props[CounterMain], "main")
// The system will shut down after the CounterMain actor stops (not shown here)
// For a clean shutdown, you could use CoordinatedShutdown or watch termination manually
}
¿Qué es el modelo del que venimos hablando?
Siempre que un actor reciba un mensaje puede hacer cualquier combinación de las siguientes acciones:
- Crear mensajes: comunicarse con otros actores de manera asíncrona
- Crear actores (hijos de sí mismo): crear actores hijos para delegar trabajo o estructurar el sistema de manera jerárquica
- Cambiar su comportamiento para próximos mensajes de manera dinámica
Los actores encapsulan tanto estado como comportamiento, permitiendo concurrencia sin locks y segura al reaccionar a los mensajes.
Encapsulación de los Actores
No tienen getters ni setters, se debe manejar su estado a través de mensajes
Están aislados: no se puede acceder al estado ni a su comportamiento de manera directa, sólamente interactuando desde el
lado de otro actor (via pasaje de mensajes usando direcciones conocidas, sus ActorRef
)
- Cada actor conoce su referencia (
self
) - Crear un actor devuelve su propia referencia.
- Las referencias (o direcciones) se pueden compartir y pasar entre mensajes (ej: usando
sender
)
Este modelo fuerza aislamiento y previene problemas de memoria compartida como condiciones de carrera
Orden de evaluación de los Actores
-
Cada actor dentro de sí mismo es single-threaded, con lo cual los mensajes van llegando secuencialmente
- Llamar a
context.become
cambia su comportamiento frente al próximo mensaje - Cada mensaje es atómico, ya que no existe el interleaving entre actores
- Llamar a
-
Los actores procesan un mensaje a la vez
- No hay overlap entre manejadores de mensajes
- Los cambios de comportamiento aplican al próximo mensaje
- La atomicidad asegura actualizaciones seguras del estado local
Es muy parecido al
synchronized
de Java, solo que sin bloqueo; en su lugar se encolan los mensajes.
Trade-Offs
- Te atás al asincronismo, no tenés respuestas inmediatas
- No existe memoria compartida (esto es importante)
- Cada actor tiene sus propias variables y espacios de memoria alocados
- Solo se comparte memoria a través de mensajes
- Añade una capa de complejidad importante
- Es más difícil de debuggear
Esto lo anoté en base a lo que me dijeron los profes